Skip to content

feat(html): make editing one mode every format shares - #871

Merged
andiwand merged 11 commits into
mainfrom
feat/editing-mode-generic
Sep 10, 2026
Merged

feat(html): make editing one mode every format shares#871
andiwand merged 11 commits into
mainfrom
feat/editing-mode-generic

Conversation

@andiwand

@andiwand andiwand commented Sep 10, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Editing was a sheet feature that happened to live in a script. This makes it a
mode every format shares, and answers the two questions
editing.md left open about HtmlConfig::editable.

The mode is generic

frontend/editing.js owns odr.editing for every document view: the mode,
the refusal table, the log a save reads, the odr.onEdit* callbacks and
odr.generateDiff. A format attaches its own editor to it:

Script What it is now
editing.js the mode. Written by every document view
document.js the text editor, attached. Still the skeleton — contenteditable runs, a MutationObserver, no undo
sheet-editing.js the cell overlay, attached. The locks, the position map, setCell

So a .docx view answers isEditable() the way an .ods already did, and a
host greys its edit button before a tap rather than after one. The public
surface a host wires does not change: enable, disable, isEnabled,
isEditable, undo, redo, getOperations, committed, lockAt,
refuseAt, editAt and the three callbacks are all where they were.

HtmlConfig::editable writes the scaffolding, not contenteditable

It writes what the mode cannot work out for itself — data-odr-path on every
editable run, the lock on a locked cell, the state on <body>, and the editor
script — and the mode writes contenteditable on those runs when a host
calls enable(). Two consequences:

  • switching modes needs no second render, which is what decision 3 of
    spreadsheet-editing.md asked for;
  • a render with editable off carries no editing markup at all, so a read-only
    view pays nothing for an editor it cannot reach.

The document's editable state moved off the .odr-sheet table onto <body>,
which is where a text view can carry it too. The table keeps data-odr-sheet.

A host keeps the keys it needs

keyboard_navigation and keyboard_shortcuts (both on by default) decide
whether the page takes the keys that move the selection and the undo chord. The
sheet registers its handler in the capture phase and calls preventDefault, so
before this an embedder with its own arrow-key bindings lost them with no way to
ask for them back. The open editor's own keys — Escape, Enter, Tab — are never
taken away, because they are the only way out of it.

Two bugs the split exposed

  • A read-only text document took the Enter key from the reader and reported
    error 1 for it: document.js's handler was global and unconditional. It now
    fires only inside an editable run while the mode is on, and it goes through
    onEditRefused (reason newLine, still code 1) because a refusal is
    expected UX rather than a fault.
  • A sheet page swallowed Enter whenever no cell was pinned, for the same reason.

Checks

  • odr_test full suite: 1633 passed, 6 skipped, none failed.
  • test/browser/sheet headless: tests 14, positions 20, sorting 8,
    editing 51, and the new keyboard 10 — all passing. keyboard.html is a
    page whose config took both key classes away.
  • test/browser/annotation: all passing. viewport is unchanged and needs a
    real window.
  • wasm node suite: 43 passing.
  • Every touched TU syntax-checked under g++-15 -Wall -Wextra -Werror, and
    clang-tidy over them reports only a dead store that predates this branch.

Reference output

682 files, and every difference is one of: the two attributes added to <body>,
data-odr-editable gone from the sheet table, contenteditable gone from a
run, the editing.js link, and — on the two -read-only variants — the locks
and the editor scripts gone. Proved by normalising both sides for exactly those
tokens and comparing the rest line by line: nothing else moved, so no pixel
does either.

andiwand and others added 11 commits September 10, 2026 09:07
The mode, the refusal channel and the dirty flag are what a host wires, and
it wires them once per document - so they cannot live in the sheet's script.
`editing.md` gains decisions 9 to 12: one generic `odr.editing` an editor
attaches to, the frame stated on `<body>`, `HtmlConfig::editable` as the
switch that writes the scaffolding, and a config for the keys a host may
need back. `spreadsheet-editing.md` keeps the cell's own decisions and
points at the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
The mode, the refusal channel and the dirty flag are what a host wires, and
it wires them once per document, so they cannot sit in the sheet's script.
`frontend/editing.js` owns `odr.editing` for every document view and each
format attaches its own editor: the cell overlay for a sheet, the runs for a
text document. A `.docx` view answers `isEditable()` the way an `.ods`
already did, so a host can grey its edit button before a tap.

`HtmlConfig::editable` writes the scaffolding the mode needs rather than
`contenteditable`: the addressing an op names, the lock on a locked cell, the
state on `<body>`, and the editor script. The mode writes `contenteditable`
on the addressed runs when the host turns it on, so switching modes needs no
second render, and a read-only render carries none of it.

`keyboard_navigation` and `keyboard_shortcuts` let a host keep the arrow keys
and the undo chord, which the sheet takes in the capture phase. The open
editor's own keys are never taken away, because they are the way out of it.

Also fixes two things the split exposed: a read-only text document took the
Enter key from the reader and reported an error for it, and a sheet page
swallowed Enter whenever no cell was pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
Also restores `plain_text`'s doc comment, which sat above `writes_editable`
and went with it, and corrects `write_spreadsheet_script`'s: the script
beside it is the editing one now, not the document one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
A click on the pinned cell clears the pin, and the second click of a double
click was taking it - so double-clicking a cell to select a word flashed the
border on and off again. `detail` counts the clicks, and only a single one
clears now. `positions.html` checks it, and fails without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
`contenteditable` per run made every run its own editing host, and a host is
a wall: the caret could not cross it, a selection could not span two of them,
and a reader who selected a sentence got nothing - silently. The mode now
puts `contenteditable` on the body and the editor gates `beforeinput`, so a
document behaves like one and every edit it cannot replay is refused with a
reason a host can show.

The whitelist is closed: only the input types that change the text of one
addressed run pass. Refusing something we could have allowed costs a reader
one gesture; allowing something no op can name costs them their document.
Nothing is marked non-editable - an edit is allowed because it lands inside a
`data-odr-path` run, so a picture, a table's furniture and the gap between
two paragraphs are refused without an attribute of their own.

New reasons: `unsupportedEdit` (7) and `range` (8). The observer reports code
9 where text changed outside every run, which is the hole a composition or an
incomplete `beforeinput` would open. `test/browser/text` holds the cases, and
`checks.js` moved up beside `serve.py` because two directories share it now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
A `MutationObserver` cannot tell a reader's edit from a script rewriting the
page, and `search.js` rewrites plenty: highlighting nine matches put nine
no-op `setText` ops in the log and lit the host's save button. `input` is the
browser saying it applied an edit, which a script never raises, so the two
are no longer confused. The run is the one the caret sits in, falling back to
the one `beforeinput` named; where neither answers, code 9 says the gate has
a hole rather than dropping the edit in silence.

Also records the two limits a reader meets in `editing.md` decision 13 - undo
belongs to the browser until phase 3 gives the editor an inverse, and
backspace at the start of a run is refused because merging two runs is not
something `setText` can express - and adds `test/browser/text`, whose README
says why no check may use `execCommand`: Chrome's scripted editing raises no
cancelable `beforeinput` and dissolves a run whose whole text it replaces,
neither of which trusted input does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
@andiwand
andiwand merged commit 6f3b1af into main Sep 10, 2026
36 checks passed
@andiwand
andiwand deleted the feat/editing-mode-generic branch September 10, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant